package cz.fi.muni.pb138.spring2013.dtd2xsd.parse;

import cz.fi.muni.pb138.spring2013.dtd2xsd.entities.Attribute;
import cz.fi.muni.pb138.spring2013.dtd2xsd.entities.AttributeType;
import cz.fi.muni.pb138.spring2013.dtd2xsd.entities.ChoiceRestriction;
import cz.fi.muni.pb138.spring2013.dtd2xsd.entities.Document;
import cz.fi.muni.pb138.spring2013.dtd2xsd.entities.ElementRestriction;
import cz.fi.muni.pb138.spring2013.dtd2xsd.entities.SequenceRestriction;
import cz.fi.muni.pb138.spring2013.dtd2xsd.entities.SimpleElementRestriction;
import cz.fi.muni.pb138.spring2013.dtd2xsd.visitor.interfaces.ExtendedContentRestriction;
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Scanner;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * This class is responsible for parsing the input DTD.
 *
 * @author Valdemar Svabensky
 * @author Martin Strharsky
 */
public class Parser {

    private Document document = new Document();
    private Map<String, ElementRestriction> parsedElements = new HashMap<>();

    /**
     * The "main" method which controls the parsing process from opening the
     * input XML file to constructing in-memory schema.
     *
     * @param filePath input XML file
     * @return constructed "semi-schema" object model or null in case of error
     */
    public Document parse(String filePath) {
        List<String> unparsedElements = new ArrayList<>();
        List<String> unparsedAttlists = new ArrayList<>();

        StringBuilder unparsedDTD = getUnparsedDTD(filePath);
        if (unparsedDTD == null) {
            return null;
        }
        unparsedDTD = Utilities.removeMultipleWhiteSpace(unparsedDTD);

        separate(unparsedDTD, unparsedElements, unparsedAttlists);

        parseElements(unparsedElements);
        parseAttlists(unparsedAttlists);
        document.setRoot(parsedElements.get(getRootName(unparsedDTD)));

        parsedElements = new HashMap<>();
        Document doc = document;
        document = new Document();

        return doc;
    }

    /**
     * Opens the input XML file, bites out the DTD part and sets XMLContent for
     * current document.
     *
     * @param filePath path to input XML file
     * @return internal and external DTD all together
     */
    private StringBuilder getUnparsedDTD(String filePath) {
        StringBuilder unparsedDTD = new StringBuilder();
        BufferedReader br = null;
        try {
            /* Read input XML file line by line */
            br = new BufferedReader(new FileReader(filePath));
            StringBuilder inputFileBuilder = new StringBuilder();
            String currentLine;
            boolean possibleStructuredAttlist = false; // see test IN08
            while ((currentLine = br.readLine()) != null) {
                if (currentLine.contains("<!ATTLIST")) {
                    possibleStructuredAttlist = true;
                }
                if (possibleStructuredAttlist) {
                    inputFileBuilder.append(" ").append(currentLine);
                    if (currentLine.contains(">")) {
                        possibleStructuredAttlist = false;
                    }
                } else {
                    inputFileBuilder.append(currentLine);
                }
            }
            String inputFile = inputFileBuilder.toString();

            StringBuilder XMLContentBuilder = new StringBuilder();
            StringBuilder afterDTD = null;
            try (Scanner scanner = new Scanner(inputFile)) {
                XMLContentBuilder.append(scanner.findInLine(".*?<!DOCTYPE ").replaceFirst("<!DOCTYPE ", ""));
                unparsedDTD.append("<!DOCTYPE ");
                String doctypeInsides = scanner.findInLine(".*?]>");
                if (doctypeInsides != null) {
                    unparsedDTD.append(doctypeInsides);
                } else {
                    unparsedDTD.append(scanner.findInLine(".*\">"));
                }
                final String afterDTDString = scanner.findInLine(".*");
                if (afterDTDString != null)
                    afterDTD = new StringBuilder(afterDTDString);
            }
            if (afterDTD != null) {
                afterDTD = Utilities.removeMultipleWhiteSpace(afterDTD);
                afterDTD = new StringBuilder(afterDTD.toString().replaceAll(">", ">" + System.lineSeparator()));
                XMLContentBuilder.append(System.lineSeparator()).append(afterDTD);
                XMLContentBuilder = new StringBuilder(XMLContentBuilder.toString().replaceAll(">" + System.lineSeparator() + " ", ">" + System.lineSeparator()));
            }
            document.setXMLContent(XMLContentBuilder.toString());

            /* Check for external DTD */
            String[] tokens = unparsedDTD.toString().split(" ", 5);
            if ("SYSTEM".equals(tokens[2])) {
                String uri = tokens[3].replaceAll("\"", ""); // DTD file name
                if (uri.substring(uri.length() - 1).equals(">")) {
                    uri = uri.substring(0, uri.length() - 1);
                }
                if (!uri.contains("/")) {
                    filePath = filePath.replace("\\", "/");
                    String XMLfilename = filePath.substring(filePath.lastIndexOf('/') + 1);
                    uri = filePath.replaceAll(XMLfilename, uri);
                }
                unparsedDTD.append(extractExternalDTD(uri));
            }
        } catch (IOException ex) {
            System.err.println(Arrays.toString(ex.getStackTrace()));
            return null;
        } finally {
            try {
                if (br != null) {
                    br.close();
                }
            } catch (IOException ex) {
                System.err.println(Arrays.toString(ex.getStackTrace()));
                return null;
            }
        }
        return unparsedDTD;
    }

    /**
     * Helper method for getUnparsedDTD() to get External DTD. External DTD is
     * appended to string containing internal DTD. In case of conflict, the
     * first occurrence of a given definition counts, the others will be
     * overridden.
     *
     * @param filePath path to external DTD file
     * @return unprocessed external DTD or null in case of error
     */
    private static String extractExternalDTD(String filepath) {
        StringBuilder externalDTD = new StringBuilder();
        BufferedReader br = null;
        try {
            br = new BufferedReader(new FileReader(filepath));
            String currentLine;
            while ((currentLine = br.readLine()) != null) {
                if (!currentLine.startsWith("<?")) {
                    externalDTD.append(currentLine);
                }
            }
        } catch (IOException ex) {
            System.err.println(Arrays.toString(ex.getStackTrace()));
            return null;
        } finally {
            try {
                if (br != null) {
                    br.close();
                }
            } catch (IOException ex) {
                System.err.println(Arrays.toString(ex.getStackTrace()));
                return null;
            }
        }
        return externalDTD.toString();
    }

    /**
     * Splits the unparsedDTD to tokens with format "<!*?>" and checks
     * every created String. If it starts with !ELEMENT, then adds the String to
     * unparsedElements list. If it starts with !ATTLIST, then adds the String to
     * unparsedAttlists list.
     *
     * @param unparsedDTD
     * @param unparsedElements
     * @param unparsedAttlists
     */
    private void separate(StringBuilder unparsedDTD,
            List<String> unparsedElements, List<String> unparsedAttlists) {

        String[] tokens = unparsedDTD.toString().split("[^<!--]<!.*?");
        for (String line : tokens) {
            line = line.trim();
            line = line.replaceAll(">", "");
            line = line.trim();
            if (line.endsWith("]")) // valid for last item in unparsedDTD
            {
                line = line.substring(0, line.length() - 1);
            }
            line = line.trim();

            StringBuilder tmp = new StringBuilder(line);
            tmp = Utilities.removeMultipleWhiteSpace(tmp);
            line = tmp.toString();

            if (line.startsWith("ELEMENT")) {
                unparsedElements.add(line);
            } else if (line.startsWith("ATTLIST")) {
                String lineCopy = line;
                lineCopy = lineCopy.replaceAll("\".*?\"", ""); // so that we can count the number of spaces
                if (lineCopy.length() - lineCopy.replaceAll(" ", "").length() > 5) {
                    List<String> result = splitStructuredAttlist(line);
                    for (String attlist : result) {
                        unparsedAttlists.add(attlist);
                    }
                } else {
                    unparsedAttlists.add(line);
                }
            }
        }
    }

    /**
     * Helper method for "separated". Splits attlist that is "all in one".
     *
     * @param line "all in one" attlist
     * @return lists of separate attlists, each with new <!ATTLIST ... prefix
     */
    private static List<String> splitStructuredAttlist(String line) {
        List<String> result = new ArrayList<>();
        String[] tokens = line.split(" ", 3);
        String heading = tokens[0] + " " + tokens[1] + " ";

        Scanner scanner = new Scanner(tokens[2]);
        final String matchBracketsPattern = "\\(.*?\\)";
        final String matchHashPattern = "#.*?";
        final String matchQuotesPattern = "\".*?\"";
        while (true) {
            if (!scanner.hasNext()) {
                break;
            }
            String attributeName = scanner.next();
            String attributeType;
            if (scanner.hasNext(matchBracketsPattern)) {
                attributeType = scanner.next(matchBracketsPattern);
            } else {
                attributeType = scanner.next();
            }

            String attributeValueType = null;
            String attributeDefaultValue = null;
            if (scanner.hasNext(matchHashPattern)) {
                attributeValueType = scanner.next(matchHashPattern);
            }
            if (scanner.hasNext(matchQuotesPattern)) {
                attributeDefaultValue = scanner.next(matchQuotesPattern);
            }

            StringBuilder newAttlist = new StringBuilder(heading);
            newAttlist.append(attributeName).append(" ").append(attributeType);
            if (attributeValueType != null) {
                newAttlist.append(" ").append(attributeValueType);
            }
            if (attributeDefaultValue != null) {
                newAttlist.append(" ").append(attributeDefaultValue);
            }

            result.add(newAttlist.toString());
        }
        return result;
    }

    /**
     * Sends all elements to method parseSimpleElement
     *
     * @param input list of unparsed elements
     */
    private void parseElements(List<String> input) {
        for (String element : input) {
            parseSingleElement(element);
        }
    }

    /**
     * Parses single element. Form of input is "ELEMENT" + "name" + "something".
     * Create ElementRestriction with the name "name". If "something" equals
     * EMPTY, ANY or PCDATA, create SimpleRestriction accordingly. Otherwise
     * sends "something" to parseBrackets method.
     *
     * @param element element to be parsed
     */
    private void parseSingleElement(String element) {
        String[] parts = element.split(" ", 3);
        ElementRestriction er = new ElementRestriction();
        boolean isInMap = parsedElements.containsKey(parts[1]);

        if (isInMap) {
            er = parsedElements.get(parts[1]);
            if (er.getContent() != null) {
                return;
            }
        } else {
            er.setName(parts[1]);
        }
        parts[2] = parts[2].replaceAll("\\s", "");

        switch (parts[2]) {
            case "EMPTY":
                er.setContent(SimpleElementRestriction.EMPTY);
                break;
            case "ANY":
                er.setContent(SimpleElementRestriction.ANY);
                break;
            case "(#PCDATA)":
                er.setContent(SimpleElementRestriction.PCDATA);
                break;
            default:
                parseBrackets(er, parts[2]);
        }

        if (!isInMap) {
            parsedElements.put(er.getName(), er);
        }

    }

    /**
     * Sends all attlists to method parseSimpleAttlist
     *
     * @param input list of unparsed attlists
     */
    private void parseAttlists(List<String> input) {
        for (String attlist : input) {
            parseSingleAttlist(attlist);
        }
    }

    /**
     * Parses single attlist. Form of input is "ATTLIST" + "elem_name" +
     * "att_name" + "something". Creates Attribute with the name "att_name".
     * Compares "something" to AttributeTpoe. If there is no match, sets
     * AttributeType to ENUMERATED and adds list of enumerated values.
     *
     * @param attlist attlist to be parsed
     */
    private void parseSingleAttlist(String attlist) {
        List<String> enumValues = new ArrayList<>();
        String[] tokens = attlist.split(" ", 3);
        Scanner scanner = new Scanner(tokens[2]);
        final String matchBracketsPattern = "\\(.*?\\)";
        final String matchHashPattern = "#.*?";
        final String matchQuotesPattern = "\".*?\"";

        String attributeValueType = null;
        String attributeDefaultValue = null;
        String attributeName = scanner.next();
        String attributeType;

        if (scanner.hasNext(matchBracketsPattern)) {
            attributeType = scanner.next(matchBracketsPattern);
            attributeType = attributeType.replaceAll("\\s", "");
            attributeType = attributeType.substring(1, attributeType.length() - 1);
            for (String s : attributeType.split("\\|")) {
                enumValues.add(s);
            };
            attributeValueType = "ENUMERATED";
        } else {
            attributeType = scanner.next();
        }

        if (scanner.hasNext(matchHashPattern)) {
            attributeValueType = scanner.next(matchHashPattern);
        }
        attributeDefaultValue = scanner.findInLine(matchQuotesPattern);

        if (attributeDefaultValue != null) {
            attributeDefaultValue = attributeDefaultValue.replace("\"", "");
        }
        Attribute attribute = new Attribute();
        attribute.setName(attributeName);

        switch (attributeType) {
            case "CDATA":
                attribute.setType(AttributeType.CDATA);
                break;
            case "ID":
                attribute.setType(AttributeType.ID);
                break;
            case "IDREF":
                attribute.setType(AttributeType.IDREF);
                break;
            case "IDREFS":
                attribute.setType(AttributeType.IDREFS);
                break;
            case "NMTOKEN":
                attribute.setType(AttributeType.NMTOKEN);
                break;
            case "NMTOKENS":
                attribute.setType(AttributeType.NMTOKENS);
                break;
            default:
                attribute.setType(AttributeType.ENUMERATED);
                attribute.setEnumeratedValues(enumValues);
        }

        if (attributeValueType == null) {
            attributeValueType = "";
        }
        switch (attributeValueType) {
            case "#REQUIRED":
                attribute.setRequired(true);
                attribute.setFixed(false);
                if (attributeDefaultValue != null) {
                    attribute.setDefaultValue(attributeDefaultValue);
                }
                break;
            case "#IMPLIED":
                attribute.setRequired(false);
                attribute.setFixed(false);
                break;
            case "#FIXED":
                attribute.setRequired(false);
                attribute.setFixed(true);
                attribute.setDefaultValue(attributeDefaultValue);
                break;
            default:
                if (attributeDefaultValue != null) {
                    attribute.setDefaultValue(attributeDefaultValue);
                }
        }

        parsedElements.get(tokens[1]).addAttribute(attribute);

    }

    /**
     * Parses brackets and decides if input is choice or sequence. Than calls
     * adequate methods (parseChoice or parseSequence).
     *
     * @param element all will be set to this element
     * @param input input which has to be parsed
     */
    private void parseBrackets(ElementRestriction element, String input) {

        if (input.indexOf(',') == -1 && input.indexOf('|') == -1) {
            SequenceRestriction sr = new SequenceRestriction();
            input = setCardinalityMultiple(sr, input);

            if (parsedElements.containsKey(input)) {
                ElementRestriction er = parsedElements.get(input);
                input = setCardinalitySingle(er, input);
                sr.addElement(er);
            } else {
                ElementRestriction er = new ElementRestriction();
                input = setCardinalitySingle(er, input);
                er.setName(input);
                sr.addElement(er);
                parsedElements.put(input, er);
            }
            element.setContent(sr);

        } else if ((input.indexOf(',') < input.indexOf("|") && input.indexOf(',') != -1) || input.indexOf('|') == -1) {
            SequenceRestriction sr = new SequenceRestriction();

            parseSequence(sr, input);
            element.setContent(sr);
        } else if ((input.indexOf(',') > input.indexOf("|") && input.indexOf('|') != -1) || input.indexOf(',') == -1) {
            ChoiceRestriction cr = new ChoiceRestriction();

            parseChoice(cr, input);
            element.setContent(cr);
        }
    }

    /**
     * Sets cardinality for single element. Form of element is
     * element_name[*+?]?
     *
     * @param element cardinality will be set for this element
     * @param input input with one of these chars (*,+,?)
     * @return input without cardinality char
     */
    private String setCardinalitySingle(ExtendedContentRestriction element, String input) {
        switch (input.charAt(input.length() - 1)) {
            case '*':
                element.setRequired(false);
                element.setMultiple(true);
                input = input.replace("*", "");
                break;
            case '+':
                element.setRequired(true);
                element.setMultiple(true);
                input = input.replace("+", "");
                break;
            case '?':
                element.setRequired(false);
                element.setMultiple(false);
                input = input.replace("?", "");
                break;
            default:
                element.setRequired(true);
                element.setMultiple(false);
        }

        return input;
    }

    /**
     * Sets cardinality for group of elements. Form of input is
     * (element_name1,...,element_nameN)[*+?]?
     *
     * @param element cardinality will be set for this group elements
     * @param input input with one of these chars (*,+,?)
     * @return input in form element_name1,...,element_nameN
     */
    private String setCardinalityMultiple(ExtendedContentRestriction element, String input) {
        switch (input.charAt(input.length() - 1)) {
            case '*':
                element.setRequired(false);
                element.setMultiple(true);
                break;
            case '+':
                element.setRequired(true);
                element.setMultiple(true);
                break;
            case '?':
                element.setRequired(false);
                element.setMultiple(false);
                break;
            default:
                element.setRequired(true);
                element.setMultiple(false);
        }

        if (element.isRequired() && !element.isMultiple()) {
            input = input.substring(1, input.length() - 1);
        } else {
            input = input.substring(1, input.length() - 2);
        }

        return input;
    }

    /**
     * Parses a sequence of elements and adds elements to it.
     *
     * @param element sequence in which elements will be added
     * @param input input which has to be parsed
     */
    private void parseSequence(SequenceRestriction element, String input) {
        input = setCardinalityMultiple(element, input);

        List<String> parts = new ArrayList<>();
        Pattern pattern = Pattern.compile("(\\(.*?\\)[)*?+]*[,]?)|([#]?\\w+[*?+]?[,]?)");
        Matcher matcher = pattern.matcher(input);

        while (matcher.find()) {
            parts.add(matcher.group());
        }

        for (String item : parts) {
            if (item.endsWith(",")) {
                item = item.substring(0, item.length() - 1);
            }
            if (item.contains("(")) {
                if ((item.indexOf(',') < item.indexOf("|") && item.indexOf(',') != -1) || item.indexOf('|') == -1) {
                    SequenceRestriction sr = new SequenceRestriction();
                    parseSequence(sr, item);
                    element.addElement(sr);
                } else {
                    ChoiceRestriction cr = new ChoiceRestriction();
                    parseChoice(cr, item);
                    element.addElement(cr);
                }
            } else {
                if (item.equals("#PCDATA")) {
                    element.addElement(SimpleElementRestriction.PCDATA);
                } else {
                    ElementRestriction er = new ElementRestriction();
                    item = setCardinalitySingle(er, item);

                    if (parsedElements.containsKey(item)) {
                        parsedElements.get(item).setMultiple(er.isMultiple());
                        parsedElements.get(item).setRequired(er.isRequired());
                        element.addElement(parsedElements.get(item));
                    } else {
                        er.setName(item);
                        parsedElements.put(item, er);
                        element.addElement(er);
                    }
                }
            }
        }
    }

    /**
     * Parses choice of elements and adds elements to it.
     *
     * @param element choice in which elements will be added
     * @param input input which has to be parsed
     */
    private void parseChoice(ChoiceRestriction element, String input) {
        input = setCardinalityMultiple(element, input);

        List<String> parts = new ArrayList<>();
        Pattern pattern = Pattern.compile("(\\(.*?\\)[)*?+]*[\\|]?)|([#]?\\w+[*?+]?[\\|]?)");
        Matcher matcher = pattern.matcher(input);

        while (matcher.find()) {
            parts.add(matcher.group());
        }

        for (String item : parts) {
            if (item.endsWith("|")) {
                item = item.substring(0, item.length() - 1);
            }
            if (item.contains("(")) {
                if ((item.indexOf(',') < item.indexOf("|") && item.indexOf(',') != -1) || item.indexOf('|') == -1) {
                    SequenceRestriction sr = new SequenceRestriction();
                    parseSequence(sr, item);
                    element.addElement(sr);
                } else {
                    ChoiceRestriction cr = new ChoiceRestriction();
                    parseChoice(cr, item);
                    element.addElement(cr);
                }
            } else {
                if (item.equals("#PCDATA")) {
                    element.addElement(SimpleElementRestriction.PCDATA);
                } else {
                    ElementRestriction er = new ElementRestriction();
                    item = setCardinalitySingle(er, item);

                    if (parsedElements.containsKey(item)) {
                        parsedElements.get(item).setMultiple(er.isMultiple());
                        parsedElements.get(item).setRequired(er.isRequired());
                        element.addElement(parsedElements.get(item));
                    } else {
                        er.setName(item);
                        parsedElements.put(item, er);
                        element.addElement(er);
                    }
                }
            }
        }
    }

    /**
     * Gets name of root element.
     *
     * @param unparsedDTD extern and intern DTD together
     * @return name of root element
     */
    private static String getRootName(StringBuilder unparsedDTD) {
        return unparsedDTD.toString().split(" ", 3)[1];
    }
}
